在上一篇的「房門與門鎖」篇章中,我們已經完成了登入 / 註冊表單的設計,讓使用者能夠順利進出這棟「房子」🙂。但光是蓋好門,還不足以保證整體的品質。接下來就要進入新的篇章 — 「屋況驗收」。
就像房子蓋好之後,還需要驗收管線、電路、水泥結構是否牢靠一樣,在軟體開發中,我們也需要透過測試來驗證功能是否真的能如預期運作。本篇將帶你初始化 Vitest,並撰寫第一個單元測試,一步步為系統打下更可靠的基礎👍。
validSchema.test.ts
的完整測試結果本篇重點整理:
validSchema.ts
為例,展示部分程式碼參考這邊來延續本系列的傳統來說明本次會用到的工具:
或許你會想:
我在瀏覽器跑一跑,出錯不就會看到白屏或錯誤訊息了嗎🤔?
問題是,專案越大、功能越多,錯誤可能發生在許多環節,並不會乖乖顯示在哪裡爆掉。更糟的是,今天你修好了登入功能,明天加了新需求卻不小心把舊功能搞壞了😓,這就是所謂的回歸錯誤。
測試的價值在於:
就像蓋房子時,如果沒有驗屋員逐一檢查水電、結構,搬進去後才發現問題,代價會非常高。
在專案中常見的測試大致分為:
authApiCall.ts
、passwordStrength.ts
和 validSchema.ts
LoginForm.tsx
、InputField.tsx
而在本篇,我們會專注在「 單元測試 」,驗證一些最小單位的邏輯與 UI 元件。
寫測試的時候,並不是只有「正常情況」要檢查,還需要從不同角度來驗證,確保功能真的健壯:
這通常是 bug 容易藏身的地方,寫測試能避免這些情況影響使用者體驗。
其實沒有一定的標準,但有一些比較實務的節點可以參考:
🔰上一個主題「房門與門鎖」本身就是一個功能的收尾,把測試篇章放在這裡是為了呈現一個「 功能開發 → 驗收測試 」的完整流程。往後每個主題收尾時就不多加贅述與撰寫測試篇章。
在 React + TypeScript 專案中,我們可以這樣安裝測試工具:
npm i -D vitest happy-dom @testing-library/jest-dom @testing-library/react @testing-library/user-event
接著,創建 vitest.config.ts
:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom",
globals: true,
setupFiles: "./src/setupTests.ts",
},
});
新增 setupTests.ts
:
import "@testing-library/jest-dom";
並在 package.json
的 script
中加入:
"test": "vitest"
這樣就完成基本初始化了 🎉
通常,單元測試會是所有測試檔案裡行數最多的,畢竟他是整個測試的根基,必須要將底打好,為後續的整合測試做好鋪墊。
所有的測試檔案都會是 .test.xx
形式,像是 validSchema.test.ts
與 InputField.test.tsx
以下我用一個簡單範例做解說:
import { describe, it, expect } from "vitest";
import { emailSchema } from "../lib/validSchema";
describe("emailSchema 驗證", () => {
it("應該通過合法的 email", async () => {
const result = await emailSchema.isValid("test@example.com");
expect(result).toBe(true); // ✅ 預期為 true
});
it("應該拒絕不合法的 email", async () => {
const result = await emailSchema.isValid("invalid-email");
expect(result).toBe(false); // ❌ 預期為 false
});
});
expect(result).toBe(true)
代表:如果結果不是 true,測試就會失敗。簡單認識後,接下來就開始製作吧!
validSchema.test.ts
describe("validSchema 單元測試", () => {
describe("Login Schema 登入驗證", () => {
it("應該成功驗證有效的登入資料", async () => {
const validLoginData = {
username: "testuser123",
password: "Password123!",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(validLoginData)).resolves.toBe(
validLoginData
);
});
it("應該在使用者名稱為空時拋出錯誤", async () => {
const invalidLoginData = {
username: null,
password: "Password123!",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 使用者名稱為必填欄位"
);
});
it("應該在使用者名稱少於 7 個字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "short",
password: "Password123!",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 使用者名稱最少為 7 個字元"
);
});
it("應該在使用者名稱超過 16 個字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "toolongusername12345",
password: "Password123!",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 使用者名稱最多為 16 個字元"
);
});
it("應該在使用者名稱包含無效字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "invalid@user",
password: "Password123!",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 使用者名稱只能包含英文字母、數字、底線( _ )和減號( - )"
);
});
it("應該在密碼為空時拋出錯誤", async () => {
const invalidLoginData = {
username: "testuser123",
password: null,
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 密碼為必填欄位"
);
});
it("應該在密碼少於 8 個字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "testuser123",
password: "short",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 密碼最少為 8 個字元"
);
});
it("應該在密碼超過 20 個字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "testuser123",
password: "toolongpassword1234567890",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 密碼最多為 20 個字元"
);
});
it("應該在密碼包含無效字元時拋出錯誤", async () => {
const invalidLoginData = {
username: "testuser123",
password: "password with space",
recaptcha: "some-recaptcha-token",
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 密碼只能包含英文字母、數字和常見特殊字元"
);
});
it("應該在 reCAPTCHA 為空時拋出錯誤", async () => {
const invalidLoginData = {
username: "testuser123",
password: "Password123!",
recaptcha: null,
};
await expect(loginSchema.validate(invalidLoginData)).rejects.toThrowError(
"* 請完成 reCAPTCHA 驗證"
);
});
});
});
參考資料